Skip to content

Consolidated toolbar during command execution - #1745

Draft
tleonhardt wants to merge 40 commits into
mainfrom
consolidated_toolbar
Draft

tleonhardt wants to merge 40 commits into
mainfrom
consolidated_toolbar

Conversation

@tleonhardt

@tleonhardt tleonhardt commented Sep 5, 2026 •

Copy link
Copy Markdown
Member

Summary

Today enable_bottom_toolbar=True only shows a toolbar while prompt-toolkit is waiting for input — it vanishes the moment a command starts running. This branch makes the toolbar persistent: it stays visible and keeps refreshing while commands execute, with command output scrolling above it.

Because a persistent toolbar owns the bottom of the terminal, anything else that wants the terminal has to coordinate with it. That drives the two other pieces of this branch: an embedded pager that shares the toolbar's application, and a suspension mechanism for the places that genuinely need the raw terminal (nested prompts, shell commands, Python/IPython shells, external pagers, and pipes to interactive processes).

What changed

Persistent toolbar (cmd2/command_toolbar.py, new)

  • CommandToolbar reuses main_session.app and its existing toolbar container rather than standing up a second prompt-toolkit application, so content callback, styling, and refresh interval are shared with the prompt.
  • Commands still run on the main thread; the command loop drives the toolbar UI from a background thread.
  • Stable ToolbarStream wrappers route Python-level and piped subprocess output through prompt-toolkit's output proxy so writes land above the toolbar. Their identities stay consistent across cmd2 redirection and toolbar suspension.
  • An RLock shared by the toolbar and its streams serializes writes against the proxy swap during pause/resume.
  • Input stays coordinated: cursor position reports are handled, type-ahead is saved for the next prompt, Ctrl-C is forwarded to the process group (os.killpg on POSIX, _thread.interrupt_main() on Windows), and Ctrl-Z suspend-to-background is honored where supported.
  • If the display thread dies on its own, _app_exited() detaches the streams so output goes back to the real terminal and the error is reported, instead of writing into a dead proxy.

Embedded pager (cmd2/pager.py, new)

  • ppaged() uses a built-in pager that shares the toolbar's application, so the toolbar keeps rendering while you scroll. Supports colors, wrapped lines, chopped lines with horizontal scrolling (including on Windows, which the external more never did), search (/, n/N), g/G, and q.
  • New use_builtin_pager attribute, defaulting to enable_bottom_toolbar. Set it to False to keep using the configured external pager/pager_chop; an external pager takes over the terminal, so that path still suspends the toolbar.

Suspension (cmd2/cmd2.py)

  • New public Cmd.suspend_bottom_toolbar() context manager for application code that calls input(), drives its own terminal UI, or starts a subprocess that inherits the terminal.
  • Applied internally via a @suspend_toolbar decorator on _read_raw_input(), select(), _run_shell_command() (which runs both shell and the editor), _run_python(), do_ipy(), and _run_cmdfinalization_hooks().
  • read_input() and read_secret() show the same toolbar in their own prompt, refreshing at the main prompt's refresh_interval, so it stays visible while a command waits for input. select() does not, because prompt-toolkit's choice() cannot refresh one.
  • Pipes to interactive targets: the pipe process now inherits the real terminal through the new pipe_target() helper, and the toolbar suspends for the life of the pipe (held in RedirectionSavedState.pipe_context, released in _restore_output()). Streams with no file descriptor still fall back to PIPE. Previously alias | fzf-style commands produced no output at all.
  • The toolbar is started around onecmd_plus_hooks() from the interactive loop and around startup commands. It is not started for non-interactive input or for onecmd_plus_hooks() calls made outside the loop.
  • A failing toolbar.stop() no longer disables the toolbar for the rest of the session — _command_toolbar = None moved into its own finally.

Notes and trade-offs

  • get_bottom_toolbar() now runs in a background UI thread during command execution. Applications that read state a command mutates should guard it with a lock. This is documented.
  • The embedded pager is deliberately basic (scroll + search). Applications wanting full less behavior opt out with use_builtin_pager = False.
  • One guarded dependency on PromptSession's internal layout structure, verified against prompt-toolkit 3.0.52 and 3.0.53; it raises a clear RuntimeError if the structure moves.
  • Ctrl-\ (SIGQUIT) is inert while the toolbar is up because raw mode clears ISIG. This matches existing behavior at the main prompt, so it is documented rather than changed.

Tests

  • New tests/test_command_toolbar.py and tests/test_pager.py (91 tests) covering startup/shutdown, output routing, unfinished lines, suspension paths, pipe handling, Ctrl-C and Ctrl-Z, display failures, the pager, and the EOF shutdown path.
  • tests/test_pipeline_job_control.py gains a real-pty test which suspends and resumes the pager under an interactive bash.
  • Includes a fix for Windows CI: the fixture now binds the app session to the test pipe and recording output, so patch_stdout() cannot lazily construct a real Win32Output and raise NoConsoleScreenBufferError on runners with no console screen buffer.

Docs

  • docs/features/prompt.md: persistent behavior, refresh, the pager controls, suspend_bottom_toolbar(), and threading guidance.
  • docs/features/os.md and docs/features/initialization.md: use_builtin_pager and the external-pager opt-out.
  • examples/getting_started.py: new work [seconds] command demonstrating the toolbar clock updating while a command prints output.
  • CHANGELOG entry under 4.5.0.

Hardening after the first round (2026-11-14)

Found by driving the branch in a pseudo-terminal on macOS and Linux, each fixed in its own commit with a regression test:

  • Editor ran inside the live toolbar. 4.4.2 made run_editor() call _run_shell_command() directly, bypassing the suspension on do_shell(). The suspension now sits on _run_shell_command().
  • Ctrl-Z then fg left the terminal cooked on Linux. After resuming, the pager no longer answered q and cursor reports were echoed. The display thread now waits for the stop to take it before it touches the terminal again.
  • A raising get_bottom_toolbar() flooded the terminal. Each traceback asyncio logged triggered a redraw, which raised again: 229 reports during one 1.5 second command. The display now stops on its first error, reports it once, and the command carries on without a toolbar. A display that cannot restart after a pause is reported rather than raised into the command or out of cmdloop().
  • Unfinished lines of output were erased. Working... followed later by finished displayed as finished, a completed \r progress line vanished, and a final line without a newline was lost intermittently. This is upstream patch_stdout behavior for text flushed without a newline. The proxy now ends an unfinished line on screen so the toolbar starts on a row of its own, and continues the line when more output arrives. Verified with wrapped lines, wide characters, color carried across writes, and a Rich progress bar. One visible effect: a line still unfinished when a command ends is ended there, so the next prompt starts on a new line.

Known limitations

  • The toolbar is erased and redrawn around each prompt and each write. That flicker predates this branch and is not addressed here; reserved_row_toolbar is the follow-up for it.
  • Lock held across a pause. If get_bottom_toolbar() takes a lock, a command must not hold that lock while it prompts, pages, or runs a subprocess: pausing the toolbar waits for the callback to return, so the application hangs. Documented with a warning in docs/features/prompt.md. Bounding that wait is implemented on reserved_row_toolbar and comes with it.

NOTES

This is one possible architecture and approach to solving this problem.

I did a bunch of manual testing on both macOS and Linux and everything looks good there, but I need someone else to help test on Windows.

TODO

  • Improve documentation
  • Investigate slow tests on Windows - they take about 7 times as long on Windows relative to Linux. Before this change Windows tests only took about 40% longer than Linux. The culprit appears to be slow Windows tests in the test_command_toolbar.py file.
  • Investigate stuck test on Windows when specifically using Python 3.11. One of the tests in test_command_toolbar.py gets stuck indefinitely
  • Manual testing on Windows - got a colleague to verify everything behaves as expected using the getting_started.py app on Windows from this branch

Closes #1743

tleonhardt and others added 7 commits September 5, 2026 17:25
… and refreshing during command execution. Prompts, pagers, and shell commands temporarily suspend it while using the terminal.

Try the work command examples/getting_started.py for a demonstration.

The feature uses two prompt-toolkit displays at different times: the existing PromptSession while waiting for input, and a dedicated CommandToolbar while commands execute.

- Command execution stays on the main thread. The command loop starts the toolbar’s UI in a background thread, using the same content callback, styling, and refresh interval.
- Output appears above the toolbar. Stable stream wrappers route Python and piped subprocess output through prompt-toolkit’s output proxy. Their identities remain consistent across cmd2 redirection and toolbar suspension.
- Input remains coordinated. The toolbar handles terminal position reports, saves typed-ahead keys for the next prompt, and forwards Ctrl-C to the main thread.
- Other terminal interfaces get exclusive access. Nested prompts, pagers, and shell commands temporarily suspend the toolbar. Custom commands can do the same with suspend_bottom_toolbar().
- Cleanup restores normal terminal operation. When execution ends, buffered output is flushed, workers are stopped, and the original streams are restored.

Most supporting code lives in cmd2/command_toolbar.py, with lifecycle integration in cmd2/cmd2.py. Because get_bottom_toolbar() runs in the UI thread during commands, shared mutable state should be protected with a lock.
1. Pipes to interactive targets (cmd2.py:3366, command_toolbar.py:43) — the pipe process now inherits the real terminal via the new pipe_target() helper, and the toolbar suspends for the life of the pipe (held in RedirectionSavedState.toolbar_suspension, released in _restore_output). Streams with no file descriptor still fall back to PIPE. Verified under a real pty: alias | python3 -c "...isatty()" printed PIPE_ISATTY True; before the fix that command produced no output at all.

2. Ctrl-C not reaching subprocesses (command_toolbar.py:161) — POSIX now uses os.killpg(os.getpgrp(), SIGINT), matching what the terminal driver does. Verified under a pty with a child that reports its own SIGINT: post-fix CHILD_GOT_SIGINT appears and the child exits immediately; pre-fix it never fired and the child ran to completion. Windows keeps interrupt_main() — broadcasting a console control event there would hit unrelated processes on the same console. Ctrl-\ stays inert, which matches the main prompt's existing behavior; documented rather than changed.

3. Failing stop() disabling the toolbar (cmd2.py:2098) — _command_toolbar = None moved into its own finally.

4. Writes racing _pause() (command_toolbar.py) — an RLock shared by the toolbar and its ToolbarStreams serializes writes against the proxy swap. The lock is deliberately released before _thread.join(), since the toolbar thread can itself be blocked writing through a stream.

5. Post-startup failures swallowed — new _app_exited() detaches the streams when the display stops on its own, so output goes to the terminal instead of a dead proxy, and reports the error. Worth noting: the review's stated trigger doesn't hold — a get_bottom_toolbar() that raises on refresh is caught by asyncio's default handler and the thread survives. The fix targets the case where the thread genuinely dies.

6. Final flush cancelled before writing — does not reproduce. Application.run_async already awaits wait_for_cpr_responses() and any in-flight run_in_terminal futures before its loop closes (application.py:770-779). I built the exact scenario — an output with responds_to_cpr=True and a CPR future confirmed pending at pause time — and the trailing text arrived with and without the proposed fix. I reverted my drain implementation, which was adding a run_in_terminal round-trip and up to a second of CPR waiting at every command boundary for nothing. I kept the test as a regression guard and a two-line guard against Application.exit() raising in a loop callback if the app has already stopped.

7. Docs/example mismatch — do_work now uses Annotated[int, Argument(nargs="?")], so work and work 5 both work. Verified in the real app.
Three tests called _read_raw_input() after the toolbar context had exited,
so no prompt-toolkit app session was active. patch_stdout() then asked the
default app session for its output, which lazily builds a real terminal
Output: harmless Vt100_Output on POSIX, but Win32Output on Windows, which
raises NoConsoleScreenBufferError without a console screen buffer of the
kind GitHub Actions does not provide.

Bind the fixture's app session to the test pipe and recording output so
every prompt-toolkit call in these tests resolves to the test terminal
rather than the ambient one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7nkx8kKC2mpaMGfLwXe6J
…d an embedded pager

Following prompt-toolkit’s [layout composition model](https://python-prompt-toolkit.readthedocs.io/en/3.0.52/pages/full_screen_apps.html#the-layout).

- CommandToolbar (cmd2/command_toolbar.py:101) now reuses main_session.app and its actual toolbar container, eliminating the second application and duplicated display configuration.

- The new pager (cmd2/pager.py:37) shares that application. The toolbar continues refreshing during scrolling, searching, and resizing. Colors, wrapped lines, and horizontal scrolling are supported.

- Enabling the toolbar selects the embedded pager. Set self.use_builtin_pager = False to retain your external pager.

An external pager such as less takes control of the terminal; a layout alone cannot reserve space around it. That path still temporarily suspends the toolbar.

This simplifies rendering ownership, although the embedded pager adds code.

The command UI thread and output proxy remain necessary for synchronous commands. There’s also one guarded dependency on PromptSession’s internal layout structure, verified against versions 3.0.52 and 3.0.53.
Closing the terminal's input ends the display's Application.run() with
EOFError, which _resume() swallows because it is an ordinary shutdown
rather than a failure the running command should hear about. Nothing
exercised that handler, leaving it as the one uncovered line in the new
module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7nkx8kKC2mpaMGfLwXe6J
@codecov

codecov Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.77%. Comparing base (c2eef0b) to head (809a1d5).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1745      +/-   ##
==========================================
+ Coverage   99.75%   99.77%   +0.01%     
==========================================
  Files          23       25       +2     
  Lines        6609     7160     +551     
==========================================
+ Hits         6593     7144     +551     
  Misses         16       16              
Flag Coverage Δ
unittests 99.77% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

page() constructed a full Pager -- a TextArea, a SearchToolbar, an ANSI
lexer and its key bindings -- and then threw it away whenever the output
fit on screen, which is a common case for ppaged(). Only the line
measurements were ever used on that path.

Move the size check into a module-level output_fits() that measures the
text directly, and build the Pager only once the output has to be
scrolled. A shared _fragments() helper does the one ANSI parse for both,
so the measurement cannot drift from what the view would render.

The fast path's size check drops from ~0.15ms to ~0.02ms per call, so it isn't a massive speedup or anything, but still cleaner.
1. Chop-mode paging no longer loses your column — cmd2/pager.py:_scroll()
The destination column now comes from the window's current horizontal_scroll when chop is set, instead of target * window_width (always 0 in chop mode)

2. A toolbar that can't start no longer takes the session down - cmd2/cmd2.py
_command_toolbar_context() catches startup failure, reports once via perror, sets a new _command_toolbar_disabled flag so later commands don't retry and re-spam and runs the command without a toolbar

3. ppaged() no longer starts a toolbar outside the command loop — it now pages inside a toolbar only when one is already running, and otherwise falls through to the external pager. The documentation has been updated to contain truthful statements in this regard.

4. use_builtin_pager documented as opt-out only — initialization.md, os.md, prompt.md and the ppaged() docstring now state that it cannot turn the embedded pager on where no toolbar is running, since the two share one display.
…g visible hyperlink text, colors, and width calculations

When ppaged() receives Rich output containing hyperlinks, Rich emits OSC 8 sequences that prompt-toolkit's ANSI parser does not understand. Their contents become visible pager text: a linked click here renders as 8;id=...;https://example.comclick here8;;. This also corrupts width calculations and searchable text.

This change strips unsupported OSC sequences before passing captured output to cmd2's new internal pager.
…toolbar.py

Also:
- Moved common code used by test_command_toolbar.py and test_pager.py to conftest.py

Since the pager is effectively its own component, it feels cleaner to have the tests in their own file.
@tleonhardt tleonhardt self-assigned this Sep 6, 2026
tleonhardt and others added 7 commits September 6, 2026 00:10
Cause: rich's Style.render() skips the OSC 8 hyperlink wrapper entirely when legacy_windows=True. Console(force_terminal=True) on Windows auto-detects a legacy console, so the fixture string never contained \x1b]8; — the failure was in the test's setup, not in the pager.

Fix (tests/test_pager.py:31): pass legacy_windows=False to the Console, so the fixture emits real hyperlink sequences on every platform.
test_command_toolbar_ui_call_reports_display_failure patched
call_soon_threadsafe on the toolbar's live event loop and swallowed every
call, not just the one it meant to drop. The patch was still installed
while asyncio.run() tore the loop down, and asyncio resolves the default
executor's join future through call_soon_threadsafe, so that report was
dropped too.

On Linux and macOS prompt_toolkit registers stdin with loop.add_reader, no
default executor is ever created, and shutdown_default_executor() returns
immediately. On Windows prompt_toolkit parks its WaitForMultipleObjects
watcher in loop.run_in_executor(), so the executor exists and the loop
waits on a future nothing resolves: 300 seconds on Python 3.12 and later,
and forever before that, where shutdown_default_executor() takes no
timeout. Windows CI went from 34s to 345s on 3.14, and hung for six hours
on 3.11.

Drop only the request made on the command thread and pass everything else
through. The display still dies with the ValueError the test asserts on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ej3Nyn6vGngxYqm9FA1Jw5
@tleonhardt
tleonhardt marked this pull request as ready for review September 6, 2026 15:13
…Windows

It now has three commands, in the order your colleague should try them:

1. slow — sleeps 5 seconds. Does TOOLBAR IS HERE stay visible the whole time? This is the discriminating test: if the toolbar isn't up during a command, the real bug is that CommandToolbar never starts on Windows, and the pager is just collateral damage.

2. longout — pages 500 lines. Built-in pager ends in q: quit; the external one shows -- More --.

3. diag — dumps the guard state to real stderr (so it survives whatever the pager does to the screen).

The single most useful thing to report is the slow result plus the diag block — that alone should help narrow down the underlying root cause.
@tleonhardt
tleonhardt marked this pull request as draft September 6, 2026 19:05
read_input() and read_secret() each build their own PromptSession and passed no
bottom_toolbar, while _read_raw_input() suspends the main display. The toolbar
therefore vanished entirely for as long as a nested prompt was up, which is a
visible inconsistency for a bar documented as persistent.

Pass the toolbar to both sessions when the application has one, gated on
main_session.bottom_toolbar being set so an app without a toolbar does not sprout
one. Also inherit refresh_interval, so a clock or status display keeps ticking
while the nested prompt waits rather than freezing at its first render.

select() is deliberately left alone: prompt-toolkit's choice() accepts a toolbar
but has no refresh_interval, so its toolbar would go stale while the selection
sits idle.
…nd a UI-call race (#1752)

* Render test output independently of the caller's environment

Rich and cmd2 consult several environment variables when deciding whether to
emit styling, and the suite inherited them. Exporting any one of them made large
numbers of unrelated tests fail depending on who ran the suite: NO_COLOR failed
15 tests, and FORCE_COLOR and TTY_COMPATIBLE 53 each. The failures look like
product regressions, which makes them expensive to diagnose.

Neutralize them for every test. Tests that exercise these variables set them
explicitly, which still works because a test's own monkeypatching runs after the
fixture. A guard test fails if any of them reaches a test again.

* Write redirected and piped output as UTF-8

Command output is rendered by Rich and routinely contains non-ASCII, but
redirection targets and pipes were opened with the locale's encoding. On any
system whose default is not UTF-8 -- a Windows console using a legacy code page,
for instance -- redirecting output raised UnicodeEncodeError, and the user was
left with an empty file and advice to set PYTHONIOENCODING.

Open both with UTF-8 explicitly. Two tests that read redirected output back were
relying on the locale encoding for decoding as well, so they now name it too.

* Fix a race that reported a timeout for a successful UI call

_call_in_ui() polls the pending future with a 0.1s timeout and, on expiry,
re-raises when the future is already done. That branch exists because
concurrent.futures.TimeoutError is TimeoutError on Python 3.11+, so a callback
raising a timeout of its own cannot be told apart by type from the poll expiring.

Re-raising the caught exception conflates the two. When the callback completes
in the window between the poll expiring and the future being inspected, the
caller is told the call timed out even though it succeeded. Ask the future for
its outcome instead: a callback that raised a timeout still propagates it, and
one that produced a value now returns it.

Found while investigating an intermittent failure of
test_command_toolbar_ui_call_propagates_failures, which reproduced once in 60
runs before this change and not once in 120 after. The added regression test
drives the interleaving deterministically rather than relying on timing.

(cherry picked from commit 84bc19e)

* Run the pipe encoding test through this interpreter, not cat

The test piped through `cat`, which cmd.exe does not provide. On a Windows
system without Unix utilities installed it would fail before reaching the
encoding behavior it exists to check -- and Windows is exactly what the UTF-8
redirection fix targets.

Use a sys.executable pass-through instead, matching the pipe tests already in
tests/test_command_toolbar.py. Reverting either the pipe or the redirect
encoding still fails these tests.
Measured which Windows ANSI code pages can represent the box-drawing characters
Rich emits: every cp125x code page fails, covering US and Western European,
Central European, Cyrillic, Greek, Turkish, Hebrew, Arabic, Baltic and
Vietnamese systems. Only the CJK double-byte code pages survive.

Calling it a legacy code page was wrong: cp1252 is the default on a current,
fully updated Windows 11, and Python only defaults to UTF-8 mode in 3.15.
@tleonhardt

Copy link
Copy Markdown
Member Author

This PR is a better attempt at making the bottom bar persistent. It works in nearly all situations, but involved some complexities like needing to introduce code to support a custom pager. It works and has been validated to do what it is intended to do. However it continues to have an annoying issue with flicker of the bottom bar that has always existed.

While we could review it and merge it in, I'm holding off on that for now as I explore options for truly eliminating the flicker to provide a better user experience. As I've learned that is not an easy problem to solve.

tleonhardt and others added 13 commits September 8, 2026 17:23
# Conflicts:
#	CHANGELOG.md
#	cmd2/cmd2.py
#	cmd2/utils.py
#	tests/conftest.py
# Conflicts:
#	CHANGELOG.md
#	docs/features/prompt.md
While a command runs, the bottom toolbar's display renders to the main
session's output from its own thread. set_window_title() only found that
display's loop through active_session, which still names the original
session when main_session is replaced after construction, so a title set
during a command could be written and flushed from the calling thread,
unsynchronized with the toolbar's rendering.

Treat the toolbar's display like a prompt: queue the title on its event
loop while it runs, and write any title whose callback the loop didn't run
once the display has stopped.
run_editor() now calls _run_shell_command() directly instead of going through do_shell(), so the
suspension on do_shell() no longer covered the editor. Suspend around _run_shell_command(), which
both callers share.
Ctrl-Z in the built-in pager, or at the command toolbar, sends SIGTSTP from the
display thread. A process-directed signal lands on the main thread, and on
Linux the display thread carries on for a few milliseconds: it restores raw
mode, reattaches its input and redraws before the job has stopped. When the
shell resumes the job it puts back the terminal modes it saved beforehand, and
nothing is left to undo that. The terminal stays cooked, keys are held until
Enter, and with a refreshing toolbar every cursor-position query is echoed as
^[[row;colR.

After sending the signal, wait for the stop to take the sending thread as
well. A stop shows as time passing while asleep; a signal that was ignored, or
discarded for an orphaned process group, shows as nothing and the wait ends on
its own.

This is the fix made in 66513f6 on stage4-handoffs-integration, without its
reserved toolbar parts. The regression test drives the pager under an
interactive bash on a pty, suspends and resumes it, and requires that it still
answers q. It fails on Linux without this change.
An error raised while the toolbar redraws during a command, most likely from
get_bottom_toolbar(), went to asyncio's default handler, which logs a traceback
to stderr. That write passes through the stdout proxy, whose redraw calls the
failing callback again, so one faulty callback printed hundreds of tracebacks
per second for as long as the command ran.

Handle errors on the display's event loop by stopping the display, which
reports the error once through the path that already existed for a display
that stops on its own. A display which has failed is not restarted for the
rest of the command, and a display which cannot come back after a pause is
reported instead of raised: that exception would otherwise fail the command
which paused it, or escape cmdloop() from command finalization.
Pausing the toolbar waits for the refresh in progress, so a command holding a
lock which get_bottom_toolbar() acquires must not prompt, page or run a
subprocess while it holds it. Say so where the documentation recommends a lock.

Also note at the key_bindings swap that prompt-toolkit caches merged bindings
per focused window and does not see the attribute being reassigned.
prompt-toolkit draws the toolbar starting at the cursor, and erases it from the
first column of that row before printing anything more above it. Output which
stopped in the middle of a line was erased along with the toolbar: a line
written as 'Working...' and finished later lost its first part, a progress line
redrawn with carriage returns vanished once it was complete, and a last line
without a newline was lost whenever the display redrew before it stopped.

End an unfinished line on the screen so the toolbar always starts on a row of
its own, and when more output arrives move back up and write the unfinished
line again before it, which puts the cursor and any style it set where they
were. A line unfinished when the display stops stays ended, so the next prompt
starts on a new line, as it does with reserved rendering.
Stopping the display after an error draws a last frame and fires after_render
again. A handler which fails there replaced the error already recorded, so the
report named a consequence instead of the cause.

That is how a race in the pager navigation test surfaced on CI. Its
after_render check read the terminal's size after another thread had resized
it, so a frame drawn at the old size was judged against the new one. asyncio
used to log and ignore that assertion. Now it stops the display, and the check
failed again on the last frame with an unrelated AttributeError. Judge each
frame against the size it was drawn at.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bottom bar should persist during command execution

1 participant